feat: delete tag#3025
Conversation
|
Thanks for the pull request, @jesperhodge! This repository is currently maintained by Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review. 🔘 Get product approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere can I find more information?If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources: When can I expect my changes to be merged?Our goal is to get community contributions seen and reviewed as efficiently as possible. However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #3025 +/- ##
==========================================
- Coverage 95.47% 95.44% -0.04%
==========================================
Files 1383 1383
Lines 32645 32631 -14
Branches 7472 7454 -18
==========================================
- Hits 31168 31144 -24
- Misses 1408 1418 +10
Partials 69 69 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
mgwozdz-unicon
left a comment
There was a problem hiding this comment.
In addition to my comments that I think should be addressed, I have a note for the future: I think this PR contains too much preemptive refactoring outside the scope of the Github Issue requested functionality given that we're trying to preserve hours (pulling Actions.tsx out of tagColumns.tsx into its own file, renaming Tag to Row, and addressing the TODO to introduce a React context instead of passing props). I think these were all good changes that improved the quality of the code, and I like that. However, given that we're trying to preserve hours, I think this went beyond the scope of the ticket (unless the other PR reviewers disagree, in which case I defer to their preferences). Additionally, the preemptive refactoring also increases the PR size. imo, refactors are a better candidate for splitting PRs than attempting to split functionality. Also, while we're trying to preserve hours, I know our reviewers are tight on time as well with the code cut coming up and may want to be able to approve the functionality for the release and save the refactor to review when they have more time available. Given that the investment is already in here for refactoring, I don't think it should be pulled out at this time, just noting for the future.
Additionally, I believe you mentioned this before but I just don't want to lose sight of it: Do we need a separate backend PR for the Meilisearch again to ensure that the deletion really does apply across all tagged content and Libraries?
Upon testing locally, there are some interesting things I noted, but I think they might be expected behavior and/or unrelated to this ticket, but I'll call them out just in case:
- When I delete a tag that was associated to a piece of content and then I return to the Course Outline page for that content, the tag counts still include the prior deleted tag in the count but if you view the tag count, the deleted tag is gone and once you refresh the page, the tag counts are updated.
- When I delete a tag that was associated to a piece of Library content and then I return to the Library, I see the same thing: Tag count is unchanged. However, also when I click on the piece of content, the tag also still appeared to exist until I did a hard refresh (Cmd+Shift+R) on the page. After the hard refresh, the count is still not updated, but when I click on the content, the tag is gone. As expected, the Meilisearch is not updated.
| import { Row } from '@tanstack/react-table'; | ||
|
|
||
| const DELETE_CONFIRM_MESSAGE = | ||
| 'Warning: are you sure you want to delete this tag and all its subtags and descendants? Any tags applied to course content will be deleted.'; |
There was a problem hiding this comment.
I was wondering why this wasn't internationalized and then I remembered that it's because this functionality isn't intended to be here long term. These aspects should probably be annotated with the TODO comments like you had in #3024 assuming that the decision is to continue having these as separate PRs. Alternatively, I think maybe this PR should have been in a Draft state until testing artifacts can be removed.
| axiosMock.onGet(subTagsUrl).reply(200, subTagsResponse); | ||
| cleanup(); | ||
| ({ axiosMock } = initializeMocks({ user: adminUser })); | ||
| axiosMock |
There was a problem hiding this comment.
Why are these mocks initialized twice? Is one set redundant?
| }); | ||
| }); | ||
|
|
||
| it('does not issue a delete request when browser confirmation is canceled and leaves the table unchanged', async () => { |
There was a problem hiding this comment.
I was expecting this test to have something about clicking a cancel button in it. I think it's missing because it's using the browser confirmation instead of the modal. Just noting that this will probably ultimately need to be updated.
| const [tagTree, setTagTree] = useState<TagTree | null>(null); | ||
| const [isCreatingTopTag, setIsCreatingTopTag] = useState(false); | ||
| const [activeActionMenuRowId, setActiveActionMenuRowId] = useState<RowId | null>(null); | ||
| const [, setActiveActionMenuRowId] = useState<RowId | null>(null); |
There was a problem hiding this comment.
Please remove dangling comma.
There was a problem hiding this comment.
This is correct JS syntax. The array has two items, I'm grabbing the second one. The first one is not saved as an unused variable - which the linter forbids - but instead omitted. Without the comma, the code will be incorrect.
There was a problem hiding this comment.
Sorry, looking at it closer, maybe I'm used to seeing this syntax as const [_, setActiveActionMenuRowId] = useState<RowId | null>(null); to clarify, but it seems like either way is fine.
There was a problem hiding this comment.
Apparently my suggestion fails the validation tests, so definitely nevermind on this.
| draftError: string | undefined; | ||
| isError: boolean | undefined; | ||
| isUpdateError: boolean | undefined; | ||
| isAdditionalError?: boolean; |
There was a problem hiding this comment.
I find this variable name to be a little confusing. What is an "AdditionalError"? I think isError corresponds to the error on create and the isAdditionalError is corresponding to the error on delete, but if that's the case then I think the variable names should be updated to be more explicit about what they represent. Alternatively, maybe they could be consolidated to something like hasMutationError that works for all of them?
There was a problem hiding this comment.
I agree isAdditionalError isn't a very clear name here. If we're specifically using it for delete errors then isDeleteError could work. I do think that a future cleanup could be to replace the multiple error type flags with a single isError var that can be set to different error types, but I'd prefer that be a follow-up and not part of this PR.
brian-smith-tcril
left a comment
There was a problem hiding this comment.
Overall I think this is looking good. I left a few comments with questions.
I also agree with Mary that adding a refactor to a new feature PR isn't ideal (especially this close to code cut). More lines to review means more chances for bugs to slip through the review process, and the added time pressure of getting the review done in time for the cut makes that even more likely.
That isn't to say I don't like the refactor, it just would have been nice to have a small PR following existing patterns for the cut, and a refactor as a fast-follow after the cut.
I don't think this PR should be reworked to not include the refactor (we've already started the review process), just a thing to keep in mind for the future.
| draftError: string | undefined; | ||
| isError: boolean | undefined; | ||
| isUpdateError: boolean | undefined; | ||
| isAdditionalError?: boolean; |
There was a problem hiding this comment.
I agree isAdditionalError isn't a very clear name here. If we're specifically using it for delete errors then isDeleteError could work. I do think that a future cleanup could be to replace the multiple error type flags with a single isError var that can be set to different error types, but I'd prefer that be a follow-up and not part of this PR.
| try { | ||
| // In view mode, the table reloads on change, reflecting the deletion | ||
| // without needing to manually update the table state | ||
| enterViewMode(); | ||
| await deleteTagMutation.mutateAsync({ value: rowData.value, withSubtags: shouldDeleteSubtags }); | ||
| setToast({ | ||
| show: true, | ||
| message: intl.formatMessage(messages.tagsDeleteSuccessMessage, { count }), | ||
| }); | ||
| } catch (error) { | ||
| const errorMessage = getErrorMessage(error); | ||
| setDraftError(errorMessage); | ||
| setToast({ show: true, message: intl.formatMessage(messages.tagDeleteErrorMessage, { errorMessage }) }); | ||
| } |
There was a problem hiding this comment.
Claude flagged this as a possible race condition, I asked Claude to elaborate and got this response:
enterViewMode(); // 1
await deleteTagMutation.mutateAsync({ ... }); // 2
setToast({ ... }); // 3The intent (per the comment) is that view mode auto-reloads from the server when data changes, so after the delete succeeds and queries are invalidated, the table refreshes automatically without manually removing the row from the tree.
On success this works fine. The chain is: enter view mode → delete succeeds → onSuccess invalidates queries → refetch → useEffect rebuilds tree → deleted tag gone.
On failure it gets murkier. The catch block does:
enterViewMode(); // already happened
// ...mutation fails...
setDraftError(errorMessage);
setToast({ ... });The SaveErrorAlert needs both isAdditionalError (from deleteTagMutation.isError) and draftError to be truthy — so the error should render. But the mode transition already happened before the failure, so the table is sitting in view mode looking completely normal while the request is in flight. There's no loading/pending state for the delete — the user gets no feedback that anything is happening, then an error alert appears seemingly out of nowhere.
The bigger concern is that nothing disables the UI during the delete. hasOpenDraft is false (we're in view mode, not draft mode), so all the action menu buttons remain enabled. The user could start an edit, create, or trigger another delete while the first delete is still in flight. If both complete, you get interleaved state updates.
So "race condition" was probably too strong — it's more of a missing in-progress state. The question for the PR author is: should there be some guard (even a simple deleteTagMutation.isPending check) that prevents other actions while a delete is in flight?
| // If the tag in the frontend state does not have subtags, | ||
| // don't allow the backend to delete subtags. | ||
| // That prevents problems in case of stale frontend state. |
There was a problem hiding this comment.
Could you elaborate on this a bit? If the frontend shows a tag without any subtags, but the backend has subtags for that tag, and then this runs, could that result in orphaned subtags on the backend?
There was a problem hiding this comment.
I believe the backend guards against that case here: https://github.com/openedx/openedx-core/blob/6777915a956795ffd421baaeb9fc49ce50cad696/src/openedx_tagging/models/base.py#L691
I'll update the code comment to this for clarity:
// Only request recursive deletion when the frontend has loaded descendants.
// If this state is stale and the backend finds subtags while with_subtags is false,
// the backend rejects the request instead of deleting the parent alone.
|
Superseded by #3035 |
* feat: delete tag * fix: lint * fix: cleanup from comments on PR #3025 * fix: implement disableTagActions logic * fix: remove unused variable * fix: fix unsafe optional chaining lint * feat: Add delete modal to delete functionality * fix: code coverage * fix: lint formatting --------- Co-authored-by: Jesper Hodge <[email protected]>
Description
This is part of openedx/modular-learning#260 - a split-out so that it's smaller PRs to review.
The intention is to implement the actual deletion logic here, but not implement the Delete Modal yet.
The Delete Modal is replaced by a simple browser confirm for now, the PR to implement that is #3024.
To deliver the Delete functionality cleanly, I have included quite a bit of refactoring, which includes adding a React context.
Both PRs are designed to be independent, mergable in any order.
Important
After merging the other PR, the browser confirm should be replaced with its
<DeleteModal>.Note to reviewer
If there's missing test coverage, please allow me to delay addressing that until it the PR undergone a first review.
Testing instructions
Please see AC of the related ticket for exact behavior to test: openedx/modular-learning#260.
But: note that anything related to the delete modal is in the sister PR.
Use of AI
Best Practices Checklist
We're trying to move away from some deprecated patterns in this codebase. Please
check if your PR meets these recommendations before asking for a review:
.ts,.tsx).propTypesanddefaultPropsin any new or modified code.src/testUtils.tsx(specificallyinitializeMocks)apiHooks.tsin this repo for examples.messages.tsfiles have adescriptionfor translators to use.../in import paths. To import from parent folders, use@src, e.g.import { initializeMocks } from '@src/testUtils';instead offrom '../../../../testUtils'